/*
 * Sapphire
 *
 * Copyright (C) 2018 Florrie Haero
 * Copyright (C) 2018 Alyssa Rosenzweig
 * Copyright (C) 2018 eq
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02111-1301  USA
 *
 */

'use strict';

let otrKey = null;

if (!localStorage.otrKey) {
    otrKey = new window.DSA();
    localStorage.otrKey = otrKey.packPrivate();
} else {
    otrKey = window.DSA.parsePrivate(localStorage.otrKey);
}

const otrTag = window.OTR.makeInstanceTag();

const otrOptions = {
    fragment_size: 800,
    send_interval: 200,
    priv: otrKey,
    instance_tag: otrTag
};

const {
    accountDropdown: tmplAccountDropdown,
    buddyRoomHeader: tmplBuddyRoomHeader,
    chat: tmplChat,
    chatRoomHeader: tmplChatRoomHeader,
    icon: tmplIcon,
    message: tmplMessage,
    messageGroup: tmplMessageGroup,
    sidebarGroup: tmplSidebarGroup,
    user: tmplUser
} = window.templates;

const addBuddyButton = document.getElementById('add-buddy');
const app = document.getElementById('app');
const buddyList = document.getElementById('buddy-list');
const changeSettingsButton = document.getElementById('change-settings');
const chatForm = document.getElementById('chat-form');
const chatList = document.getElementById('chat-list');
const chatTextInput = document.querySelector('[data-id="input"]');
let chatUserList = document.getElementById('chat-user-list');
const loginForm = document.getElementById('login-form');
const loginPasswordInput = document.getElementById('password');
const loginUsernameInput = document.getElementById('username');
const loginScreen = document.getElementById('login-screen');
const loginStatus = document.getElementById('login-status');
let messageList = document.getElementById('message-list');
const messageListContainer = document.getElementById('message-list-container');
const modalScreen = document.getElementById('modal-screen');
const modalContent = document.getElementById('modal-content');
const roomBackButton = document.getElementById('room-back');
let roomInfoContent = document.getElementById('room-info-content');
const toggleChatUserListButton = document.getElementById('toggle-chat-user-list');
const typingIndicatorName = document.getElementById('typing-indicator-name');
const typingIndicatorStatus = document.getElementById('typing-indicator-status');

const PROTOCOL = window.location.protocol === 'file:' ? 'http:' : window.location.protocol;
const BASE_URL = window.BASE_HOST + ':7070';
window.BASE_ICON = PROTOCOL + '//' + BASE_URL + '/icon/?name=';

// Maximum width at which the mobile layout (buddy list in a separate view
// from the main chat area) is displayed. MUST be equal to the value specified
// in the @media in style.css.
const MOBILE_WIDTH = 600;

// Values from the API; DO NOT CHANGE without updating the server.
const PURPLE_MESSAGE_SEND = 1;
const TYPING_NONE = 0; // Must ALWAYS be falsey
const TYPING_TYPING = 1;
const TYPING_STOPPED = 2;

const userDetailsDict = {};
const chatDetailsDict = {};
const roomDetailsDict = {};

/* Shadow user object representing us, segregated by account to support
 * varying aliases / avatars / etc */
const selfUser = {};

// The ID of the room you're talking in.
let chatPaneRoomID = null;

// What a MONSTER of a variable name! Tells whether we should switch to the
// chat represented by the location hash (e.g. #prpl-jabber|a/|b). This is
// true only if there even is a location.hash, and then only true until we
// switch to a chat (e.g. because we found the chat, or the user interacted
// before so).
let shouldOpenChatBasedOnInitialHash = location.hash.length > 1;

const removeChildren = function(element) {
    // Does not mutate the element - returns a new element!
    const cNode = element.cloneNode(false);
    element.parentElement.replaceChild(cNode, element);
    return cNode;
};

const isMobileView = () => window.matchMedia(`(min-width: ${MOBILE_WIDTH}px)`);

const getRoomType = function(id) {
    if (id in chatDetailsDict) {
        return 'chat';
    } else if (id in userDetailsDict) {
        return 'buddy';
    } else {
        console.warn('Called getRoomType() on a room which is not stored as a chat nor a user: ' + id);
        console.trace();
        return null;
    }
};

/* Either gets a user from the store, or gets ourself */
const getUserByID = function(id, account) {
    if (id === selfUser[account].id) {
        return selfUser[account];
    }

    return userDetailsDict[id];
};

const adjustHistoryOnJoin = function(id) {
    // OoOOoOO, Spooky history tricks!
    if (location.hash !== '#' + id) {
        // Long ago, this was an elegant, single line of code. But everything
        // changed when the Progressive Web Apps attacked. Only the Hacker,
        // master of all four use cases, could sate them, but when the world
        // needed her most, she vanished.
        const newState = {};
        if (isMobileView() && location.hash.length <= 1) {
            newState.openedFromMobileIndex = true;
        }
        history.pushState(newState, null, '#' + id);
    }

    // Now that we've opened a chat, we don't want to automatically switch
    // according to the #hash value again.
    shouldOpenChatBasedOnInitialHash = false;
};

const switchPaneRoomID = function(id) {
    /* Don't try to double switch */
    if (id === chatPaneRoomID) {
        /* We need to switch for mobile anyway */
        hideBuddyPane();

        /* And we need to adjust history anyway */
        adjustHistoryOnJoin(id);

        return;
    }

    const oldChatPaneRoomID = chatPaneRoomID;
    chatPaneRoomID = id;

    // Clear the list of unread messages, stored on the backend, if there are
    // any unread messages. (If there aren't any, there's no need to clear the
    // list - it's already empty!)
    const roomDetails = getRoomDetails(id);
    if (roomDetails.unread) {
        window.backendMarkAsRead(id);
    }

    // Also reset the unread count of this room, so that it isn't highlighted
    // as having unread messages in the buddy list.
    roomDetails.unread = 0;
    updateUnreadIcon();

    // Update classes of affected nodes.
    if (oldChatPaneRoomID) {
        updateRoomClass(oldChatPaneRoomID, 'selected');
    }
    updateRoomClass(id, 'selected');
    updateRoomClass(id, 'unread');

    // Make sure the buddy element is scrolled into view
    const roomEl = getBuddyOrChatEl(id);

    try {
        roomEl.scrollIntoView({behavior: 'instant', block: 'nearest'});
    } catch (e) {
        /* Old Firefox >_> */
        console.log('Suppressing scrolling exception (modern scrollIntoView not supported)');
    }

    // Remove any existing message elements, invalidate the group, then show any messages from that
    // chat.

    /* https://stackoverflow.com/question/3955229/remove-all-child-elements-of-a-dom-node-in-javascript,
     * answer by Maciej Gurban */
    messageList = removeChildren(messageList);

    // Display messages without flushing to improve batch performance
    lastGroup.author = null;
    for (const message of roomDetails.messages) {
        displayMessage(message, false);
    }

    // Then flush at the end.
    flushMessageGroup();

    // Scroll to the bottom. We need to do this once everything has rendered
    // in the browser, so use setTimeout() to run it immediately next "tick"
    // of JavaScript (which will be after the browser has rendered).
    setTimeout(() => {
        scrollToBottom(true);
    });

    // Switch the typing indicator to that of the new pane.
    updateTypingPane();

    // History magic
    adjustHistoryOnJoin(id);

    // Update room info bar.
    updateRoomInfoElement(id);

    // Do visual updates for mobile layout.
    hideBuddyPane();

    // If we selected a chat, emit that we've now actually joined it.
    if (getRoomType(id) === 'chat') {
        window.backendJoinChat(id);

        // Fill in the chat user list, too.
        chatUserList = removeChildren(chatUserList);
        const chatDetails = chatDetailsDict[id];
        const { users } = chatDetails;
        if (users) {
            for (const userID of users.map(u => u.id)) {
                const userDetails = getUserByID(userID, chatDetails.account);
                displayBuddyOrChatItem({
                    // Set an href for clicking to open a DM with buddies
                    html: tmplUser(userDetails, {href: userDetails.isBuddy ? '#' + userID : ''}),
                    listEl: chatUserList,
                    details: userDetails,
                    alias: userDetails.alias,
                    className: 'user',
                    // Since chat users aren't grouped, we want to effectively use
                    // the list element as the group element itself.
                    groupEl: chatUserList
                });
            }
        }

        // Also mark on the DOM that we're viewing a chat - this will cause
        // some CSS changes.
        app.classList.add('chat');
    } else {
        app.classList.remove('chat');
    }
};

const updateRoomInfoElement = function(id) {
    roomInfoContent = removeChildren(roomInfoContent);
    if (getRoomType(id) === 'chat') {
        roomInfoContent.innerHTML = tmplChatRoomHeader(chatDetailsDict[id]);
    } else if (getRoomType(id) === 'buddy') {
        roomInfoContent.innerHTML = tmplBuddyRoomHeader(userDetailsDict[id]);
    }
};

const updateUnreadIcon = function(x) {
    /* Supplied to save time looking for unread messages */

    let href = '';

    if (x || Object.keys(roomDetailsDict).some(id => roomDetailsDict[id].unread)) {
        href = 'icons/menu.svg#menu-unread';
    } else {
        href = 'icons/menu.svg#menu';
    }

    roomBackButton.lastElementChild.innerHTML = tmplIcon({href: href});
};

const offsetBuddyListIndex = function(amount) {
    let allRoomIDs;
    if (getRoomType(chatPaneRoomID) === 'buddy') {
        const allBuddyEls = Array.from(buddyList.getElementsByClassName('user'));
        allRoomIDs = allBuddyEls.map(el => el.dataset.id);
    } else {
        const allChatEls = Array.from(chatList.getElementsByClassName('chat'));
        allRoomIDs = allChatEls.map(el => el.dataset.id);
    }

    const currentIndex = allRoomIDs.indexOf(chatPaneRoomID);
    let newIndex = currentIndex + amount;
    newIndex = Math.min(allRoomIDs.length - 1, newIndex);
    newIndex = Math.max(0, newIndex);

    const newRoomID = allRoomIDs[newIndex];
    switchPaneRoomID(newRoomID);
};

const getRoomDetails = function(id) {
    if (!(id in roomDetailsDict)) {
        roomDetailsDict[id] = {
            messages: [],
            unread: 0
        };
    }

    return roomDetailsDict[id];
};

const updateUserDetailsDict = function(newDetails) {
    const {id} = newDetails;
    if (id in userDetailsDict) {
        Object.assign(userDetailsDict[id], newDetails);
    } else {
        userDetailsDict[id] = newDetails;
    }

    userDetailsDict[id] = processUser(userDetailsDict[id]);

    if (userDetailsDict[id].isBuddy) {
        displayBuddy(id);
    }
};

const updateChatDetailsDict = function(newDetails) {
    const {id, users = []} = newDetails;
    if (id in chatDetailsDict) {
        Object.assign(chatDetailsDict[id], newDetails);
    } else {
        chatDetailsDict[id] = newDetails;
    }

    // Record any present users in userDetailsDict.
    for (const userDetails of users) {
        if (!(userDetails.id in userDetailsDict)) {
            /* XXX: Get rid of that condition. The issue is that we're
             * preempting the buddy aliases (nondeterministically?) and it's
             * problematic */

            updateUserDetailsDict(userDetails);
        }
    }

    displayChat(id);
};

const onReceiveUserJoinedChat = function(data) {
    const chat = chatDetailsDict[data.chat];

    /* Insert the members */

    if (!chat.users) {
        chat.users = data.members;
    } else {
        /* Only add non-duplicates. TODO: Asymptoptic performance of the
         * functional solution is O(n^2). We probably want a hash table which
         * is O(n) */

        chat.users = chat.users.concat(data.members.filter(mem => chat.users.filter(c => c.id === mem.id).length === 0));
    }

    for (const member of data.members) {
        const userDetails = userDetailsDict[member.id];

        updateUserDetailsDict({
            id: member.id,
            alias: userDetails && userDetails.alias || member.alias,
            chat: data.chat
        });
    }
};

const getBuddyOrChatEl = function(id, listEl = null) {
    const query = `[data-id="${id}"]`;
    if (listEl) {
        return listEl.querySelector(query);
    } else {
        return buddyList.querySelector(query) || chatList.querySelector(query);
    }
};

const getBuddyOrChatGroupEl = function(title, listEl) {
    // NB: This DOES take a listEl!
    return listEl.querySelector(`.group[data-title="${title}"]`);
};

const displayBuddyOrChatItem = function({html, listEl, details, alias, className, groupEl = null}) {
    const {id, group} = details;
    const itemHTML = html;

    if (!group) {
        console.warn(`Couldn't display a buddy or chat because we don't know what group it's from. The details we do have:`, details);
        return;
    }

    const oldEl = getBuddyOrChatEl(id, listEl);
    if (oldEl) {
        // If there's already an element for this item, just replace it.
        oldEl.insertAdjacentHTML('beforebegin', itemHTML);
        oldEl.remove();
        return;
    }

    // If no groupEl was passed, find the group to put the item in.
    // If there is none, create it.
    if (!groupEl) {
        groupEl = getBuddyOrChatGroupEl(group, listEl);
        if (!groupEl) {
            const groupHTML = tmplSidebarGroup({title: group});
            listEl.insertAdjacentHTML('beforeend', groupHTML);
            groupEl = getBuddyOrChatGroupEl(group, listEl);
        }
    }

    const children = Array.from(groupEl.children);

    // Start at the first element that matches the given className.
    // This is so that we skip past, for example, the group heading.
    const startIndex = children.findIndex(el => el.classList.contains(className));

    // Find the index before which we should insert the new element.
    const aliasA = alias.toLowerCase();
    let i = startIndex;
    if (startIndex >= 0) {
        i = children.slice(startIndex).findIndex(element => {
            const aliasB = element.dataset.alias.toLowerCase();
            return aliasB > aliasA;
        });
        if (i >= 0) {
            i += startIndex;
        }
    }

    // If the index of the element we searched for is at least zero,
    // we found a result, so add the new buddy before that element.
    if (i >= 0) {
        children[i].insertAdjacentHTML('beforebegin', itemHTML);
    } else {
        // Otherwise, it means that every existing buddy should be
        // positioned before this new one (or there just aren't any
        // existing buddies yet), so add the new buddy to the end of
        // the list.
        groupEl.insertAdjacentHTML('beforeend', itemHTML);
    }
};

const displayBuddy = function(id) {
    const userDetails = userDetailsDict[id];

    displayBuddyOrChatItem({
        // Set an href - when this hash is opened (e.g. in a new tab or from a
        // bookmark), it will load this particular buddy's IM chat.
        html: tmplUser(userDetails, {href: '#' + id}),
        listEl: buddyList,
        details: userDetails,
        alias: userDetails.alias,
        className: 'user'
    });

    updateRoomClass(id, 'unread');
    updateRoomClass(id, 'selected');
    updateRoomClass(id, 'typing');
};

const displayChat = function(id) {
    const chatDetails = chatDetailsDict[id];

    displayBuddyOrChatItem({
        html: tmplChat(chatDetails),
        listEl: chatList,
        details: chatDetails,
        alias: chatDetails.name,
        className: 'chat'
    });
};

/* Updates classes on a buddy object without destroying the whole thing */

const needsClass = function(id, classname) {
    if (classname === 'unread')
        return getRoomDetails(id).unread > 0;
    else if (classname === 'selected')
        return id === chatPaneRoomID;
    else if (classname === 'typing')
        return typingStatus[id] > 0;
    else
        return false;
};

const updateRoomClass = function(id, className) {
    const needed = needsClass(id, className);
    const el = getBuddyOrChatEl(id);

    if (!el) {
        console.warn(`Couldn't update a class of a buddy or chat because its element doesn't exist. (id: ${id}, class: ${className})`);
        return;
    }

    if (needed)
        el.classList.add(className);
    else
        el.classList.remove(className);
};

/* State for last group, to avoid expensive DOM queries while writing messages.
 * Further, we don't even -flush- to the DOM until we really have to */

const lastGroup = {
    author: null,
    container: null,

    queued_html: '',
};

const flushMessageGroup = function() {
    if (lastGroup.queued_html.length > 0 && lastGroup.container) {
        lastGroup.container.insertAdjacentHTML('beforeend', lastGroup.queued_html);
        lastGroup.queued_html = '';
    }
};

/* Applies /me to a message body */

const meify = function(author, msg) {
    if (!msg.startsWith('/me '))
        return msg;

    const sans_me = msg.slice('/me '.length);
    return `<em>${author.alias} ${sans_me}</em>`;
};

const displayMessage = function(message, flush) {
    const isOurMessage = message.flags & PURPLE_MESSAGE_SEND;

    let authorDetails;
    if (isOurMessage && message.chat) {
        const chatDetails = chatDetailsDict[message.chat];
        const accountID = chatDetails.account;
        authorDetails = selfUser[accountID];
    } else if (message.who === 'system') {
        // TODO: System messages... or not. For now, ignore them.
        return;
    } else {
        const friendDetails = userDetailsDict[message.who || message.buddy];

        if (friendDetails) {
            const accountID = friendDetails.account;
            authorDetails = isOurMessage ? selfUser[accountID] : friendDetails;
        } else {
            /* Unknown user, make something up */
            authorDetails = processUser({
                alias: message.alias
            });
        }
    }

    /* Process the message content. This must be client-side to support
     * end-to-end encryption */

    const meified = meify(authorDetails, message.content);
    const sanitized = window.sanitizeHtmlString(meified);

    const messageHTML = tmplMessage(Object.assign({}, message, {
        authorDetails,
        contentHTML: sanitized,
    }));

    if (lastGroup.author !== authorDetails.id) {
        /* Flush the old group if we haven't already */
        flushMessageGroup();

        /* Create a new group, with the message at the same time to amortize the cost of creating the group */

        const groupDetails = {authorDetails};
        groupDetails.initial = messageHTML;

        const groupHTML = tmplMessageGroup(groupDetails);
        messageList.insertAdjacentHTML('beforeend', groupHTML);
        lastGroup.author = authorDetails.id;
        lastGroup.container = messageList.lastElementChild.querySelector('.message-group-content');
    } else {
        /* Otherwise, reuse the existing group, appending to the HTML queue */

        lastGroup.queued_html += messageHTML;
    }

    /* For normal (non-replay), flush immediately */

    if (flush) {
        flushMessageGroup();
    }
};

/* Enables OTR with a buddy if it's not already, returning the OTR obejct */

const getBuddyOTR = (buddy) => {
    /* Check for OTR */
    const roomDetails = getRoomDetails(buddy);

    if (!roomDetails.otr) {
        /* Initialize OTR with this person */
        roomDetails.otr = new window.OTR(otrOptions);

        /* Set aggressive policy for now */
        roomDetails.otr.REQUIRE_ENCRYPTION = false;
        roomDetails.otr.ALLOW_V3 = false;
        roomDetails.otr.SEND_WHITESPACE_TAG = true;
        roomDetails.otr.WHITESPACE_START_AKE = true;

        roomDetails.otr.on('io', (msg, meta) => {
            /* We got a message to pass directly to the socket */
            console.log('->' + msg, meta);
            window.backendMessage(buddy, msg);
        });

        roomDetails.otr.on('ui', (msg, encrypted, meta) => {
            console.log('<- ' + msg, encrypted, meta);
            /* We got a (decrypted?) message, hooray */
            onReceiveMessageDisplay({
                flags: 0,
                buddy: buddy,
                content: msg
            }, true);
        });

        roomDetails.otr.on('status', function (state) {
            console.log(state);
        });

        roomDetails.otr.on('error', (err, sev) => {
            console.error(err, sev);
        });
    }

    return roomDetails.otr;
};

const onReceiveMessage = function(message, flush) {
    if (message.chat) {
        /* mpOTR not supported */
        onReceiveMessageDisplay(message, flush);
    } else if (message.buddy) {
        if (message.flags & PURPLE_MESSAGE_SEND) {
            /* Carbon of a message we sent, either on our machine or
             * elsewhere. If it's OTR, we ignore it since it effectively
             * doesn't exist. If it's plain-text, pass is through as is */

            // TODO: startsWith instead of includes?
            if (!message.content.includes('?OTR')) {
                onReceiveMessageDisplay(message, flush);
            }
        } else {
            /* Message received, feed through OTR */
            getBuddyOTR(message.buddy).receiveMsg(message.content);
        }
    }
};

const onReceiveMessageDisplay = function(message, flush) {
    const isOurMessage = message.flags & PURPLE_MESSAGE_SEND;

    const roomDetails = getRoomDetails(message.chat || message.buddy);
    roomDetails.messages.push(message);

    // If we're currently viewing the chat that this message was sent in,
    // immediately display the message.
    if (message.chat === chatPaneRoomID || message.buddy === chatPaneRoomID) {
        if (flush) {
            const wasScrolledToBottom = isScrolledToBottom(messageList);
            displayMessage(message, true);
            scrollToBottom(wasScrolledToBottom);
        } else {
            displayMessage(message, false);
        }
    } else if (!isOurMessage) {
        // Otherwise, update the unread count - but only if WE didn't send the
        // message! (That could happen if the message was sent from a different
        // client.)
        roomDetails.unread++;
        updateRoomClass(message.chat || message.buddy, 'unread');
        updateUnreadIcon(true);
    }
};

function typingStatusToString(stat) {
    /* TODO: i18n */

    if (stat === TYPING_TYPING) return 'is typing...';
    if (stat === TYPING_STOPPED) return 'has stopped typing.';

    return '';
}

/* User -> (int) state */
const typingStatus = {};

/* Updates the active pane to display a typing indicator */
const updateTypingPane = function() {
    // TODO: Support for group chats, somehow
    if (getRoomType(chatPaneRoomID) !== 'buddy') {
        return;
    }

    const buddy = userDetailsDict[chatPaneRoomID];
    const typingState = typingStatus[chatPaneRoomID];
    const description = typingStatusToString(typingState);

    typingIndicatorName.innerText = typingState ? buddy.alias : '';
    typingIndicatorStatus.innerText = description;
};

const onTyping = function(details) {
    /* Typing indication occurs in two phases. First, we record the typing
     * status. Second, we actuate it in the GUI if relevant. */

    typingStatus[details.buddy] = details.state;

    /* Splonk it on the buddy list so CSS can do its thing */

    updateRoomClass(details.buddy, 'typing');

    /* If this buddy is the active pane, update the indicator. */

    if (chatPaneRoomID === details.buddy)
        updateTypingPane();
};

/* Transforms user details from the network to additionally include derived
 * properties suitable for templating */

const processUser = function(details) {
    return Object.assign(details, {
        avatar: window.getAvatar(details)
    });
};

const onReceiveUserDetails = function(details) {
    updateUserDetailsDict(Object.assign(details, {isBuddy: true}));

    if (shouldOpenChatBasedOnInitialHash) {
        openChatByHash();
    }
};

const onReceiveChatDetails = function(details) {
    updateChatDetailsDict(details);

    if (shouldOpenChatBasedOnInitialHash) {
        openChatByHash();
    }
};

/* A single account was received. From this, we can construct the "shadow" user
 * object */

const onReceiveAccount = function(account) {
    selfUser[account.id] = processUser(account);
};

const onReceiveBuddyStatus = function(data) {
    updateUserDetailsDict({
        id: data.buddy,
        status: data.status
    });

    if (data.buddy === chatPaneRoomID) {
        updateRoomInfoElement(data.buddy);
    }
};

const onReceiveChatTopic = function(data) {
    updateChatDetailsDict({
        id: data.chat,
        topic: data.topic
    });

    if (data.chat === chatPaneRoomID) {
        updateRoomInfoElement(data.chat);
    }
};

const isScrolledToBottom = function(element) {
    const targetY = element.scrollHeight - element.clientHeight;
    const currentY = element.scrollTop;
    const difference = targetY - currentY;
    return difference < 100;
};

const scrollToBottom = function(wasScrolledToBottom) {
    const messageEl = messageListContainer.lastElementChild;

    if (wasScrolledToBottom) {
        messageEl.scrollIntoView({behavior: 'instant', block: 'end'});
    }
};

/* Typing indicators, TODO: Less bad, timers, stopped */

let typingState = TYPING_NONE;

function sendTypingState(state) {
    /* Don't bug the server for sporadic calls */
    if (typingState === state) {
        return;
    }

    /* TODO: Buddy list, statefulness */
    window.backendTyping(chatPaneRoomID, state);

    typingState = state;
}

/* Make focus not janky, since when I start typing a message I expect to be
 * able to start, you know, typing a message */

const KEY_TAB = 9;
const KEY_ENTER = 13;
const KEY_SHIFT = 16;
const KEY_ESCAPE = 27;
const KEY_UP = 38;
const KEY_DOWN = 40;
const KEY_V = 86;

const shouldFocusTextInput = function(e) {
    // Don't need to focus it if it's already focused!
    if (document.activeElement === chatTextInput) return false;

    // Capture ctrl-V (paste):
    if ((e.ctrlKey || e.metaKey) && e.which === KEY_V) return true;

    // Don't capture special keys:
    if (e.ctrlKey || e.metaKey || e.altKey) return false;

    // Don't capture tab or enter (by itself):
    if (e.which === KEY_TAB || e.which === KEY_ENTER) return false;

    // Don't capture shift-<nothing>, but do capture anything else <shift>:
    if (e.which === KEY_SHIFT) return false;

    return true;
};

const handleKeyDown = function(event) {
    if (event.which === KEY_DOWN && event.altKey) {
        offsetBuddyListIndex(+1);
        return;
    } else if (event.which === KEY_UP && event.altKey) {
        offsetBuddyListIndex(-1);
        return;
    }

    if (shouldFocusTextInput(event)) {
        chatTextInput.focus();
    }
};

buddyList.addEventListener('keydown', handleKeyDown);
messageListContainer.addEventListener('keydown', handleKeyDown);

chatTextInput.addEventListener('keydown', event => {
    handleKeyDown(event);

    if (event.keyCode === KEY_ENTER) {
        if (!event.shiftKey) {
            event.preventDefault();
            submitChatMessage();
        }
    }
});

const updateInputHeight = function() {
    const inp = chatTextInput;
    inp.style.height = 'auto';

    const style = window.getComputedStyle(inp, null);
    const rect = inp.getBoundingClientRect();
    const padding = rect.height - parseInt(style.getPropertyValue('height'));

    inp.style.height = (inp.scrollHeight - padding) + 'px';
};

updateInputHeight();
chatTextInput.addEventListener('input', updateInputHeight);

/* Use a global buddy list handler via bubbling, rather than binding to each
 * buddy individually (slow) */

const findParentWithClass = function(e, className) {
    const path = e.path || (e.composedPath && e.composedPath());

    if (path) {
        return path.find(l => l.classList && l.classList.contains(className));
    } else {
        /* Shim for older browsers */

        let target = e.target;

        while (target && !target.classList.contains(className)) {
            console.log(target);
            target = target.parentNode;
        }

        return target;
    }
};

const roomListSwitchHandler = function(e, className) {
    // Prevent "flashing" from the underlying anchor
    e.preventDefault();

    // Only switch if this was a primary-button click without any key modifiers

    if (e.button && e.button !== 0)
        return;

    if (e.ctrlKey || e.shiftKey || e.metaKey)
        return;

    const el = findParentWithClass(e, className);

    if (el) {
        switchPaneRoomID(el.dataset.id);
    }
};

const buddyListSwitchHandler = function(event) {
    roomListSwitchHandler(event, 'user');
};

const chatListSwitchHandler = function(event) {
    roomListSwitchHandler(event, 'chat');
};

buddyList.addEventListener('mousedown', buddyListSwitchHandler);
chatList.addEventListener('mousedown', chatListSwitchHandler);

/* It's not necessary to bind to click, since it's already in the JavaScript
 * href via the hash change */

chatTextInput.addEventListener('input', () => {
    const hasText = chatTextInput.value.length > 0;

    if (!typingState && hasText) {
        /* We've started typing */

        sendTypingState(TYPING_TYPING);
    } else if (typingState && !hasText) {
        /* We've stopped typing */

        sendTypingState(TYPING_NONE);
    }
});

const submitChatMessage = () => {
    const content = chatTextInput.value;
    chatTextInput.value = '';
    updateInputHeight();

    /* Obviously if we've wiped the input, we're done typing */
    sendTypingState(TYPING_NONE);

    const emojied = window.emojify(content);

    if (content.length) {
        if (getRoomType(chatPaneRoomID) === 'buddy') {
            /* Feed through OTR */
            const otr = getBuddyOTR(chatPaneRoomID);
            otr.sendMsg(emojied);

            /* Mirror for ourselves, if it'll be OTR-encrypted (such that we
             * need the manual carbon) */

            if (otr.msgstate === window.OTR.CONST.MSGSTATE_ENCRYPTED) {
                onReceiveMessageDisplay({
                    flags: PURPLE_MESSAGE_SEND,
                    buddy: chatPaneRoomID,
                    content: emojied
                }, true);
            }
        } else {
            /* mpOTR not supported */
            window.backendMessage(chatPaneRoomID, emojied);
        }
    }
};

chatForm.addEventListener('submit', event => {
    event.preventDefault();
    submitChatMessage();

});

const showBuddyPane = function() {
    app.classList.add('show-buddy-list');
    app.classList.remove('show-chat-area');
};

const hideBuddyPane = function() {
    app.classList.remove('show-buddy-list');
    app.classList.add('show-chat-area');
};

const openChatByHash = function() {
    const id = decodeURIComponent(location.hash.slice(1));

    /* No buddy ID means we meant the buddy list, rather than any particular
     * buddy */

    if (id === '') {
        showBuddyPane();
        return;
    }

    /* Otherwise, open the room requested */

    if (id in userDetailsDict || id in chatDetailsDict) {
        switchPaneRoomID(id);
    }
};

window.addEventListener('popstate', openChatByHash);

roomBackButton.addEventListener('click', event => {
    const state = history.state || {};
    if (state.openedFromMobileIndex) {
        history.back();
    } else {
        location.hash = '#';
    }
    event.preventDefault();
});

toggleChatUserListButton.addEventListener('click', event => {
    app.classList.toggle('show-chat-user-list');
    event.preventDefault();
});

/* Login screen */

const showLoginScreen = function() {
    loginScreen.classList.add('visible');
};

const hideLoginScreen = function() {
    loginScreen.classList.remove('visible');
};

const showLoginForm = function() {
    loginForm.classList.add('visible');
    if (loginUsernameInput.value) {
        loginPasswordInput.focus();
    } else {
        loginUsernameInput.focus();
    }
};

const hideLoginForm = function() {
    loginForm.classList.remove('visible');
};

const showLoginStatus = function(text) {
    if (loginStatus.firstChild) {
        loginStatus.removeChild(loginStatus.firstChild);
    }

    loginStatus.appendChild(document.createTextNode(text));
    loginStatus.classList.add('visible');
};

const hideLoginStatus = function() {
    loginStatus.classList.remove('visible');
};

const onReceiveAuthError = function() {
    gone = false;
    showLoginScreen();
    showLoginStatus('Failed login. Try again?');
    showLoginForm();

    // Login failed, clear password.
    if (hasLocalStorage)
        localStorage.passwordHash = '';
};

const onReceiveAuthSuccess = function() {
    hideLoginScreen();
    hideLoginStatus();
};

let in_ratelimit = false;

const onReceiveRatelimit = function(data) {
    /* Slow everything down */
    in_ratelimit = true;

    setTimeout(() => {
        console.log('Clear!\n');

        /* Rate limit is over! */
        in_ratelimit = false;

        /* We're now clear to try again, reveal we were wrong */
        onReceiveAuthError();
    }, data.milliseconds);
};

const onWebSocketClosed = (was_connected) => {
    gone = false;

    /* If we're being ratelimited, don't do anything, since we already know we
     * got closed up on */

    if (in_ratelimit)
        return;

    if (was_connected) {
        showLoginScreen();
        showLoginStatus('Lost connection. Try again soon?');
        showLoginForm();
    } else {
        showLoginScreen();
        showLoginStatus('Could not connect. Try again soon?');
        showLoginForm();
    }
};

loginForm.addEventListener('submit', event => {
    event.preventDefault();

    const password = loginPasswordInput.value;
    const username = loginUsernameInput.value;

    /* Clear the password */
    loginPasswordInput.value = '';

    window.hashPassword(password).then(passwordHash => {
        // Save the username and password. If the login fails, we'll clear them.
        if (hasLocalStorage) {
            localStorage.username = username;
            localStorage.passwordHash = passwordHash;
        }

        go(username, passwordHash);
    });
});

/* Modals */

const makeAccountDropdown = function() {
    const accounts = Object.values(selfUser);
    return tmplAccountDropdown({accounts});
};

const showModal = function(contentHTML, action) {
    // Modals are for showing forms; contentHTML should contain a <form>, or
    // else a lot of the code here won't work.

    modalScreen.classList.add('visible');

    modalContent.innerHTML = contentHTML;

    const form = modalContent.querySelector('form');

    form.addEventListener('submit', event => {
        event.preventDefault();
        action(form);
    });

    form.elements[0].focus();

    const cancelButton = modalContent.querySelector('input[name=cancel]');
    if (cancelButton) {
        cancelButton.addEventListener('click', hideModal);
    }

    // Return the content element so that the caller can easily add event listeners.
    return modalContent;
};

const hideModal = () => {
    modalScreen.classList.remove('visible');
};

// Hide the modal when you click the modal screen..
modalScreen.addEventListener('click', hideModal);

// ..unless you clicked on the content.
modalContent.addEventListener('click', event => event.stopPropagation());

// Also hide the modal when you press escape.
document.body.addEventListener('keydown', event => {
    if (event.which === KEY_ESCAPE) {
        hideModal();
    }
});

addBuddyButton.addEventListener('click', () => {
    const html = window.templates.modalAddBuddy({
        accountDropdown: makeAccountDropdown()
    });

    showModal(html, form => {
        const account = form.querySelector('[name=account]').value;
        const username = form.querySelector('[name=username]').value;
        window.backendRequestBuddy(account, username, username, 'Let\'s be sapphi(DEL)c(/DEL)re buddies! <3');
        hideModal();
    });
});

changeSettingsButton.addEventListener('click', () => showChangeSettings());

const showChangeSettings = function() {
    const html = window.templates.modalChangeSettings({
        accounts: Object.values(selfUser)
    });

    const modal = showModal(html, () => {});

    for (const el of modal.getElementsByClassName('account')) {
        const accountDetails = selfUser[el.dataset.accountId];
        el.querySelector('.change-avatar').addEventListener('click', () => {
            showChangeAvatar(accountDetails);
        });
    }
};

let changeAvatarModalAccount = null;
const showChangeAvatar = function(accountDetails) {
    const html = window.templates.modalChangeAvatar({
        accountDetails
    });

    showModal(html, form => {
        const { files } = form.querySelector('[name=file]');
        const file = files[0];

        const reader = new FileReader();

        reader.onload = () => {
            const base64url = reader.result;
            // The base64 URL will start with a data: prefix. We want to send
            // just the data, so remove the prefix.
            const data = base64url.split(',')[1];

            // Set this so we can automatically close the modal once we receive
            // the changeAvatar event for this account. We set it only now that
            // we're actually uploading the new avatar. This is to deal with
            // the corner case of having this modal open while you change your
            // avatar externally (i.e. a separate client/instance/browser tab).
            // The modal on *this* instance - the one where you HAVEN'T yet
            // changed your avatar - won't close.
            changeAvatarModalAccount = accountDetails.id;

            window.backendChangeAvatar(accountDetails.id, data);
        };

        reader.onerror = error => {
            // TODO: Error handling here
            console.error(error);
        };

        reader.readAsDataURL(file);
    });
};

const onReceiveChangeAvatar = function(data) {
    // Update the account in userDetailsDict and/or selfUser, making note that
    // the user now has an avatar set if they didn't before.
    const account = data.id;
    if (userDetailsDict[account]) {
        // TODO: Rename this to "hasAvatar".
        userDetailsDict[account].hasIcon = true;
        processUser(userDetailsDict[account]);
    }
    if (selfUser[account]) {
        selfUser[account].hasIcon = true;
        processUser(selfUser[account]);
    }

    // Close the change avatar modal if this is the account that had its avatar
    // changed.
    if (account === changeAvatarModalAccount) {
        hideModal();
        changeAvatarModalAccount = null;
    }
};

/* Initialize event handlers */

window.backendHooks.message = (d) => { onReceiveMessage(d, true); };
window.backendHooks.batchMessage = (d) => { onReceiveMessage(d, false); };
window.backendHooks.flushBatched = () => { flushMessageGroup(); scrollToBottom(true); };
window.backendHooks.typing = onTyping;
window.backendHooks.newBuddy = onReceiveUserDetails;
window.backendHooks.newChat = onReceiveChatDetails;
window.backendHooks.newAccount = onReceiveAccount;
window.backendHooks.changeAvatar = onReceiveChangeAvatar;
window.backendHooks.joined = onReceiveUserJoinedChat;
window.backendHooks.buddyStatus = onReceiveBuddyStatus;
window.backendHooks.topic = onReceiveChatTopic;
window.backendHooks.autherror = onReceiveAuthError;
window.backendHooks.authsuccess = onReceiveAuthSuccess;
window.backendHooks.ratelimit = onReceiveRatelimit;
window.backendHooks.wsclosed = onWebSocketClosed;

/* Fire away */

let gone = false;
const go = function(username, passwordHash) {
    if (!gone) {
        showLoginScreen();
        showLoginStatus('Logging in...');
        hideLoginForm();

        window.backendConnect(username, passwordHash);
        gone = true;
    }
};

/* We need to check for localstorage since, although it exists in all of our
 * target browsers, it may not be allowed per the content security settings
 * (e.g. in Chromium); attempting to access it otherwise raises a security
 * error. That said, a mere 'localStorage' in window check will not reveal this
 * issue; only an attempt to access it. -AR */

let hasLocalStorage = false;

try {
    localStorage;
    hasLocalStorage = true;
} catch (e) {
    console.warn('WARNING: No local storage...\n');
    console.warn(e);
}

const storedPasswordHash = hasLocalStorage ? localStorage.passwordHash : null;
const storedUsername = hasLocalStorage ? localStorage.username : null;
if (storedUsername && storedPasswordHash) {
    // TODO: Error handling. What if the password is wrong?
    go(storedUsername, storedPasswordHash);
} else {
    showLoginScreen();
    showLoginForm();
}

//navigator.serviceWorker.register("src/service-worker.js");
